fix: accept comma-separated values on deployment target and scope flags - #692
NickJosevski wants to merge 4 commits into
Conversation
|
@NickJosevski picking this up |
| func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error { | ||
| // these flags accept a comma-separated list as well as being specified multiple times | ||
| flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) | ||
| flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) |
There was a problem hiding this comment.
Blank-drop can silently flip a tenanted deploy to untenanted. ExpandCommaSeparated drops blank entries and returns nil when everything was blank, and pkg/executor/release.go:178 routes on isTenanted := len(params.Tenants) > 0 || len(params.TenantTags) > 0.
Concrete scenario (CI is exactly where this happens): --tenant "$TENANT_A,$TENANT_B" with both variables unset/empty yields "," → expands to nil → the CLI silently submits an untenanted deployment to the environment. Before this PR the literal "," (or "") was sent as a tenant name and the server rejected it. Same class of change for --exclude-deployment-target "$X" with $X empty: the exclusion list silently becomes empty instead of erroring.
Suggest erroring (or at least warning) when a flag value expands to nothing but the flag was explicitly provided, e.g. check cmd.Flags().Changed(name) && len(expanded) == 0.
There was a problem hiding this comment.
Actioned in c64ab93. The routing claim still holds on this branch — pkg/executor/release.go:178 is isTenanted := len(params.Tenants) > 0 || len(params.TenantTags) > 0 — so an all-blank --tenant would have submitted an untenanted deployment.
I went with an error out of the expansion rather than the cmd.Flags().Changed(name) && len(expanded) == 0 check. ExpandCommaSeparated now fails on any component that is blank after trimming, and ExpandCommaSeparatedFlags propagates it as the first statement of deployRun/runbookRun:
--tenant has a blank value; check for an empty variable or a stray comma in ","
Reasons for that shape over the Changed check: it also catches the partial case ("$A,$B" with only $B empty), which len(expanded) == 0 misses and which narrows the deployment scope in exactly the same silent way; and it doesn't need each command to know which flags were explicitly provided. Error rather than warning because a blank component always means a caller-side substitution produced nothing — there is no invocation where "ignore it" is the right answer.
Covered by TestExpandCommaSeparated_RejectsBlankValues (empty string, lone comma, whitespace-only, blank in the middle, trailing comma, blank alongside a good repeat) and end-to-end by "release deploy rejects a blank comma-separated value rather than silently dropping it" in deploy_test.go.
One thing worth recording: the legacy aliases are covered too, but by accident of layering rather than by design. I drove util.ApplyFlagAliases with a throwaway test — --deployTo "," arrives on the primary flag as []string{"", ""}, and the expansion then rejects it. That only works because the expansion runs after alias application, which is the main reason I did not move the split to parse time; see my reply on the altitude thread.
| } | ||
| result := make([]string, 0, len(values)) | ||
| for _, value := range values { | ||
| for _, component := range strings.Split(value, ",") { |
There was a problem hiding this comment.
No escape hatch for names containing commas — and interactive mode now emits automation commands that re-split them. The split is unconditional, so a tenant/target/environment named e.g. Foo, Inc can no longer be passed through the primary flags at all (the PR description flags this; adding a data point on the second-order effect).
The sharper edge is the interactive echo: values chosen from a picker are backfilled into resolvedFlags and GenerateAutomationCmd emits --tenant 'Foo, Inc' (pkg/util/flag/flag.go []string case emits values verbatim, one flag per element). Pasting that "Automation Command" into CI now splits it into Foo and Inc — a hard error if those don't exist, or a deploy to the wrong tenants/targets if they do. So the interactive flow can now hand the user a re-runnable command that isn't re-runnable, with no workaround. Worth deciding on the escape-hatch question before shipping rather than after.
There was a problem hiding this comment.
Actioned in b77c149, with one residual that I think you should look at before shipping.
\, now means a literal comma. splitOnUnescapedCommas walks the value and only treats a backslash specially when it immediately precedes a comma, so a backslash anywhere else is preserved verbatim and target names like DOMAIN\host are unaffected. On the interactive side, selections are escaped on the way into resolvedFlags with executionscommon.EscapeCommas — deploy.go:269-277, run.go:337-345 and 471-479, run_by_tag.go:324-326 — so the echoed Automation Command round-trips through the split instead of re-splitting a picked name. TestEscapeCommas_RoundTripsThroughExpand pins the round trip; "release deploy treats a backslash-escaped comma as part of the value" covers the flag path end to end (--deployment-target 'Web\, Prod,Other' → ["Web, Prod", "Other"]), plus unit cases in TestExpandCommaSeparated for the escaped/unescaped mix, DOMAIN\host, and a trailing backslash.
Residual: the escape hatch only works on the primary flags, not the legacy aliases. Those are registered with util.AddFlagAliasesStringSlice, so pflag CSV-parses them before we see them. I drove ApplyFlagAliases with a throwaway test: --deployTo 'Web\, Prod' lands on the primary flag as []string{"Web\\", "\" Prod\""} — mangled, with a stray backslash and csv quote characters, not even cleanly split. Same for --env, --tag, --tenantTag, --target, --specificMachines, --exclude-target, --excludeMachines. This is pre-existing — those aliases have always CSV-split and have never been able to carry a comma — so it is not a regression from this PR, but --target is a commonly used alias and we now document an escape that silently doesn't work there. Fixing it properly means registering those aliases as StringArray and routing them through the same expansion, which in turn needs ApplyFlagAliases's Value.String() round trip fixed (it reconstructs slice values by naively splitting the bracketed string, so any value containing a comma is lossy regardless). I left that out as bigger than #556.
On the design question you raised: I think \, is the right escape rather than a quoting form, because quoting can't survive the shell — by the time we see the value the user's quotes are gone, so an inner quote convention would need its own escape anyway. The flag help for all five flags now says "or as a comma-separated list; escape a comma inside a value as '\,'".
Cross-reference: the comment on #703 (pkg/executionscommon/executionscommon.go:309) is the same finding. b77c149 is already an ancestor of nj/tier1-integration-tests, so that branch has the escape hatch — same answer there, including the alias caveat above. I have not touched #703's branch.
Open question for you: do you want the alias flags brought onto the same escaping path in this PR, or is "escape hatch on the primary flags only, legacy aliases stay CSV" the shipping position?
| } | ||
|
|
||
| func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error { | ||
| // these flags accept a comma-separated list as well as being specified multiple times |
There was a problem hiding this comment.
Altitude: the expansion lives in two run functions rather than the flag layer. Two costs:
- Same-named flags now behave differently across commands:
tenant connect --environment/-e(pkg/cmd/tenant/connect/connect.go:108) still does not split commas, so-e "dev,test"works onrelease deploybut sends the literal string ontenant connect. - Mutating
flags.X.Valueat the top of the run function creates an ordering dependency — any future code reading these flags inPreRunEor before these lines sees unsplit values, and every new command must remember to add the block.
A parse-time mechanism (a small splitting pflag.Value wrapper, or a util.StringArrayCommaSeparated(...) registration helper next to AddFlagAliasesStringSlice in pkg/util/pflagaliases.go) would give every command the behavior consistently and remove the ordering hazard.
There was a problem hiding this comment.
Both sub-points hold, but I've left the expansion where it is and want your call, because the obvious parse-time version silently undoes the blank-value fix from c64ab93.
Point 1 confirmed. tenant connect registers -e/--environment with StringArrayVarP (connect.go:108) and does no splitting, so -e "dev,test" is one environment name there and two on release deploy.
Point 2 is real but currently latent. The only PreRunE on either command is util.ApplyFlagAliases, and the expansion is the first statement of deployRun/runbookRun, so nothing reads unsplit values today. It is an invariant held by convention, which is your point.
Why I didn't move it. A splitting pflag.Value has to reject blanks in Set, and util.ApplyFlagAliases discards Set errors — pflagaliases.go:63 and :66 are both _ = primaryFlag.Value.Set(...). I checked both shapes with a throwaway test driving ApplyFlagAliases:
- with a
Valuethat errors on a blank component,--deployTo ","leaves the primary flagniland no error escapes; - with the expansion where it is now, the same input arrives as
[]string{"", ""}and fails with--environment has a blank value; ....
So parse-time splitting reintroduces exactly the silent scope change from the blank-drop thread, on the legacy alias path (--deployTo, --env, --tag, --tenantTag, --target, --specificMachines, --exclude-target, --excludeMachines).
The version that doesn't regress is three parts:
util.ApplyFlagAliasesreturnserror. 7 call sites (buildinformation upload,package upload,release create,release deploy,release progression allow,release progression prevent,runbook run) — each is already inside aPreRunEthat returnserror, so it is one line each. Checked the other aliasSetpaths: string/stringArraySetnever fail and bool aliases are fed from a bool flag's ownString(), so the new splittingValuewould be the only thing that can error.- A registration helper next to
AddFlagAliasesStringSlicewrapping a splitting/blank-rejectingpflag.Value(keepingType() == "stringArray"so help output and completion don't change; nothing in the repo callsGetStringArray, so that's free). - The split/escape logic moves out of
executionscommonintopkg/utilor a leaf package, becausepkg/utilcan't importexecutionscommon.
I think (1) is worth doing on its own merits — swallowing Set errors during alias application is a latent bug independent of this PR. What I don't want to do unilaterally is (2)+(3) plus opting the other commands into comma splitting, under a PR scoped to the deploy/runbook scope flags — there are 11 StringArray --environment flags in pkg/cmd (these two, tenant connect, and the eight account ... create commands), and changing the rest is an observable behaviour change to commands #556 doesn't mention.
Decision I need: land #556 with the expansion in the two run functions and take (1)+(2)+(3) as a follow-up that also decides which other commands opt in — or do you want all of it here, and if so should the splitting Value go on every StringArray --environment or only on the deploy/runbook ones?
|
|
||
| func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { | ||
| // these flags accept a comma-separated list as well as being specified multiple times | ||
| flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) |
There was a problem hiding this comment.
Minor: this five-line block is duplicated verbatim from deployRun. If the flag set grows (the PR description already floats --runbook-tag), the two lists have to be kept in sync by hand. A tiny shared helper would collapse both call sites, e.g. executionscommon.ExpandCommaSeparatedAll(&flags.Environments.Value, &flags.Tenants.Value, ...) taking ...*[]string.
There was a problem hiding this comment.
Actioned in 8c07b92. executionscommon.ExpandCommaSeparatedFlags(...*flag.Flag[[]string]) expands in place, so both call sites are now one call: deploy.go:202 and run.go:205.
I took *flag.Flag[[]string] rather than the ...*[]string you suggested because the flags carry their own Name, and that turned out to be load-bearing once blanks became an error (c64ab93, on the other thread) — the helper can say --tenant has a blank value instead of a message with no idea which flag it came from. A ...*[]string signature would have needed the name passed alongside each pointer, which is the two-lists-to-keep-in-sync problem again.
Covered by TestExpandCommaSeparatedFlags (mutation in place across several flags, and the error naming the offending flag).
`--deployment-target "ABC,XYZ"` was sent to the server as a single target name because the flag is a pflag StringArray, while its legacy aliases (`--target`, `--specificMachines`) are StringSlice and already split on commas. Expand comma-separated values for the environment, tenant, tenant-tag and target flags on `release deploy` and `runbook run`, so the comma form matches the repeat-the-flag form. Values that can legitimately contain a comma (--variable, --skip, package/git-resource specs) are left alone. Fixes #556 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review feedback: the five-line expansion block at the top of deployRun was duplicated verbatim in runbookRun, so any new multi-value flag has to be added to two hand-maintained lists. ExpandCommaSeparatedFlags takes the flags themselves and expands them in place, leaving one call per command. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… them
Review feedback: dropping blanks let an explicitly-provided flag expand to
nothing. Because pkg/executor/release.go routes on
`len(params.Tenants) > 0 || len(params.TenantTags) > 0`, `--tenant "$A,$B"`
with both variables unset expanded to nil and the CLI silently submitted an
*untenanted* deployment to the environment. Before this branch the literal ","
was sent as a tenant name and the server rejected it. The same class of change
applied to `--exclude-deployment-target "$X"` with $X empty, where the
exclusion list quietly became empty.
A blank component always means a caller-side substitution produced nothing, so
ExpandCommaSeparated now returns an error naming the flag and quoting the
offending value. This also covers the partial case ("$A,$B" with only $B
empty), which would otherwise have silently narrowed the deployment scope.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Review feedback: the split was unconditional, so a tenant/target/environment named e.g. "Foo, Inc" could no longer be passed through the primary flags at all. The sharper edge was the interactive echo — a value chosen from a picker is backfilled into resolvedFlags and flag.GenerateAutomationCmd emits it verbatim, so the printed "Automation Command" was not re-runnable: pasting it into CI would split "Foo, Inc" back into two names, erroring if they don't exist or deploying to the wrong tenants if they do. `\,` now means a literal comma. A backslash anywhere else is preserved verbatim, so names such as DOMAIN\host are unaffected. Interactive selections are escaped with executionscommon.EscapeCommas on the way into the automation command, so the echoed command round-trips. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
b77c149 to
788222a
Compare
Fixes #556
Root cause
--deployment-targetis declared as a pflag StringArray, which does not split on commas:pkg/cmd/release/deploy/deploy.go:173—flags.StringArrayVarP(&deployFlags.DeploymentTargets.Value, ...)Its legacy aliases are registered as StringSlice, which does split on commas:
pkg/util/pflagaliases.go:39—flags.StringSlice(alias, nil, ""), used byAddFlagAliasesStringSlicepkg/cmd/release/deploy/deploy.go:78—FlagAliasSpecificMachines = "specificMachines" // octo wants a comma separated list ... but CSV also works because pflag does it for freeSo today
--specificMachines "ABC,XYZ"and--target "ABC,XYZ"both work, but the primary--deployment-target "ABC,XYZ"sends the literal stringABC,XYZto the server, producingUnable to locate deployment target(s) named 'ABC,XYZ'. The bug is an inconsistency between aflag and its own aliases, not a missing feature.
What changed
executionscommon.ExpandCommaSeparated— splits each entry on commas, trims surroundingwhitespace, drops blanks, preserves order and duplicates. Nil in, nil out.
deployRunandrunbookRunto:--environment,--tenant,--tenant-tag,--deployment-target/--run-target,--exclude-deployment-target/--exclude-run-target.Deliberately not applied to
--variable(values are arbitrary text),--skip(step names),--package/--git-resource(structured specs),--deployment-freeze-name, or--runbook-tag.The repeat-the-flag form is unchanged, so existing scripts keep working. Expansion happens before
optionsis built, so the interactive backfill intoresolvedFlagsandflag.GenerateAutomationCmdsee the already-split values;GenerateAutomationCmdemits[]stringas one
--flag 'value'per element (pkg/util/flag/flag.go:77-84), so the echoed automationcommand stays correct and re-runnable —
--deployment-target 'ABC,XYZ'in becomes--deployment-target 'ABC' --deployment-target 'XYZ'out.Test evidence
go build ./...— clean.go test ./pkg/...— 64 packagesok, 0 failures.New tests:
TestExpandCommaSeparated(pkg/executionscommon/executionscommon_test.go) — comma form,repeated form, mixed form, values containing spaces, whitespace trimming around the comma,
tenant-tag canonical values, blank entries, nil.
release deploy accepts comma-separated targets and environments; untenanted— asserts thewire request carries
SpecificMachineNames: ["first Machine", "second Machine", "third Machine"]from
--deployment-target "first Machine, second Machine" --deployment-target "third Machine".release deploy accepts comma-separated tenants and tenant tags; tenanted.runbook run accepts comma-separated environments and targets.Open questions / options
1. Scope — this one flag, or the whole multi-value execution flag set?
--deployment-target: smallest blast radius, but leaves--environment "dev,test"still broken while
--env "dev,test"works, which is the same bug wearing a different hat.--variableand--skip: consistent, but actively harmful —--variable "Note:a,b"would silently become two malformed variables.Recommendation: keep this. Four of the five (
--environment,--tenant-tag, and bothtarget flags) already accept CSV through their own legacy aliases, so this removes an
inconsistency rather than inventing new parsing.
--tenanthas no alias and is the one genuinelynew behaviour — included because splitting environments but not tenants would be arbitrary.
Happy to drop
--tenantif reviewers prefer strict "alias precedent only".--runbook-tagwas left out (no legacy alias, selects runbooks rather than deployment scope) —flagging it since it is shaped exactly like
--tenant-tagand could reasonably be included.2. Values that legitimately contain a comma.
A target/environment/tenant named
Web, Prod, or a tenant tag whose tag name contains a comma,can no longer be passed to these flags at all — there is no escape hatch. Options:
--environment/--tenant-tag/the target flags the aliases already behaved this way, so the regression surface is
--tenantplus users who were passing commas through the primary flag names.
--deployment-target 'Web\, Prod'), mirroring the escapingrelease create --packagealready does for colons. Costs a documented syntax users must learn.StringSliceinstead of a helper. Gets CSV-quoting for free(
--deployment-target '"Web, Prod",Other') viaencoding/csv, but the quoting is obscure,it changes
--helptype display fromstringArraytostrings, and it silently reinterpretsany existing value containing a quote character.
Recommendation: ship as-is, and add escaping later only if a real customer hits a comma in a
name. Worth a reviewer's call since it is technically a breaking change for such names.
🤖 Generated with Claude Code